iT邦幫忙

2023 iThome 鐵人賽

DAY 23
0
Modern Web

深入淺出,完整認識 Next.js 13 !系列 第 23

Day 23 - 再多利用 Server 一點點:Route Handler & Server Actions

  • 分享至 

  • xImage
  •  

既然 Next.js 內建後端環境,除了處理 Pre-Rendering 和 Server Components 外,還有其他功能可以善用 server 來處理。

今天分享兩個 App Router 中的兩個功能 - Route Handler 與 Server Action。

Route Handler

Route Handler 等同 Pages Router 中的 API Router,顧名思義,我們可以將路由定義成 API endpoint,來處理 HTTP request 和 response。

要如何創建 route handler 呢?

我們只需要在 /app 中的資料夾,建一個 route.ts/js 即可,路由定義的模式跟 page.tsx 相同,也可以使用巢狀、動態路由。

舉例來說,app/api/products/route.ts 即會對應 /api/products 這支 API;app/new_api/profile/route.ts 即會對應 /new_api/profile 這支 API。

但一個資料夾中不能同時有route.tspage.tsx。不然就會吃不到 Page,並跳出 Conflicting route and page 的報錯。

Route handler 支援 GET、POST、PUT、PATCH、DELETE、HEAD、OPTIONS 七種 HTTP methods,假如使用這七種以外的 methods,Next 會 return 405 Method Not Allowed。

要使用哪個 method,就以該 method 當作 export function 的名稱。比方說:

/* app/api/products/route.ts */
export function GET() {
…
}


export function POST(){
…
}

API request 和 response,可以使用 Fetch API 的 RequestResponse 物件:

/* app/api/hello/route.ts */
export function GET() {
  return Response.json({ message: 'Hello World!' });
}

這時候打 http://localhost:3000/api/hello 這支 API ( GET ),就可以得到

{
  "message": "Hello World!"
}

除了可使用 Fetch API 原生的 Request 和 Response 物件外,Next 也有擴充兩者,開發一個 methods 更多的物件 - NextRequestNextResponse,可以操作 cookies 等等。

想了解的讀者可以參考超連結官方文件,這邊就不細介紹。


Server Actions

除了可以寫 API route 以外,App Router 也以 React Actions 為基礎,推出了一個能不另外寫 API,直接在 client-side 觸發 server 執行 functions 的方法 - Server Actions。

在開始前介紹 Server Actions 前,想先提醒大家兩件事:

  1. Server Actions 目前還處於實驗階段,不建議在 production stage 使用
  2. Turbopack 目前不支援 server action
  3. Server Actions 目前評價兩極,對部分人來說不是一個「優化」的機制,也存在一些資安疑慮,後天會跟大家分享

完賽後補充:Server Actions 已於 Next.js 14 版本進入 stable 版,詳細資訊可參考官方貼文

通常會綁定表單 <form> 使用 ,可以在表單提交後觸發 server 執行某些事項,像是對資料庫 CRUD。

舉個例子:
假如 /user 頁,包含一個註冊表單,和下方一個顯示所有用戶列表的表格。
https://ithelp.ithome.com.tw/upload/images/20230923/20161853mHv1QwHRAs.png

我希望送出註冊表單後,新的用戶會出現在下方表格裡。常見的作法我可能需要寫兩支 API:

  1. 一支 POST,負責將資料寫進資料庫
  2. 一支 GET,負責讀取並回傳資料庫裡的用戶資料

但透過 Server Actions,我們可以不開發這兩支 API,直接在 Server Actions 中定義 CRUD 的邏輯,讓用戶提交表單後,會觸發這個 action。

表單搭配 Server Actions 實作

聽起來可能有點抽象,我們直接實作一個 Server Action 來處理上述表單需求:

  1. next.config.js 的設定中啟用 Server Actions:
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverActions: true,
  },
};

module.exports = nextConfig;
  1. 接著在 app/users/page.tsx 中定義 Server Action saveData()。定義方法很簡單,我在 function 第一行加入 ‘use server’ 即可。
const saveData = () => {
'use server';
…
};

假如要讀表單的資料,saveData()可以帶一個FormData資料格式的參數,透過這個參數來取得表單內容。接著就可以在 saveData() 中加入與資料庫互動的邏輯 ( 以 Firebase Cloud Firestore 為例 ):

const saveData = async (formData: FormData) => {
'use server';
// 透過 input name 取得個別 input 的值
const input: UserData = {
name: formData.get('name') as string,
email: formData.get('email') as string,
age: Number(formData.get('age')),
};
// 將使用者輸入的內容存入 Firestore
await addDoc(collection(db, 'users'), input);
};

  1. <form> 加入 action prop,來讓表單提交後觸發 saveData()
/* app/users/page.tsx */
export default async function Page(){
    ...
    
    return(
        <form action={saveData}>
        ...
        </form>
    )
}

完成後,我們來看使用者提交表單後,會發生什麼事:
server action form demo

資料有成功被寫入,但使用者列表沒有出現最新的資料,該怎麼讓資料寫入後畫面更新呢?

使用 revalidatePath 觸發 re-render

要讓畫面更新,我們就可以使用 revalidatePath() 來告訴 Next 要重新 fetch 這個 route segment 的資料並重新渲染:

  const saveData = async (formData: FormData) => {
    'use server';
    // 透過 input name 取得個別 input 的值
    const input: UserData = {
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      age: Number(formData.get('age')),
    };
    // 將使用者輸入的內容存入 Firestore
    await addDoc(collection(db, 'users'), input);
    // 告訴 Next 重新 fetch data 和渲染 /users 的內容
    revalidatePath('/users');
  };

我們重新提交表單一次,資料庫更新後,畫面也的確自動更新了!
server action with revalidatino demo

從 Client Components 呼叫 Server Actions

有時候會希望 form 提交時能觸發一些 client-only 的功能,比方透過 ref.current.reset 清空 input。

這時候就會需要在 Client Components 呼叫 Server Actions,該怎麼做呢?

  1. 將 Server Actions 放到一個獨立的檔案:
'use server';
/* app/utils/actions.ts */

export const saveData = async (formData: FormData) => {
    ...
};

可以在專門存放 Server Actions 的檔案最上方標記 'use server',告訴 Next 這份檔案都是 Server Actions,就不用在 functions 中個別標記 'use server'。不過一旦在檔案最上方標記 'use server',這份檔案就只能 export async functions。

  1. 在 Client Components import Server Actions:
/* app/users/Form.tsx */
'use client';

import { saveData } from '../utils/actions';

export default function Form() {
...
  return (
    <>
      <form
        action={saveData}
      >
        ...
      </form>
    </>
  );
}
  1. 新增一個 async function,加入 Server Actions 和ref.current.reset():
/* app/users/Form.tsx */
'use client';

import { useRef } from 'react';
import { saveData } from '../utils/action';


export default function Form() {
  const ref = useRef<HTMLFormElement>(null);
    
  // 新增 function
  const onSubmit = async (formData: FormData) => {
    ref.current?.reset();
    await saveData(formData);
  };
    
  return (
    <>
      <form
        ref={ref}
        action={onSubmit}
      >
        ...
      </form>
    </>
  );
}

完成後,當我們提交表單,輸入框會先清空,資料也能正確寫入資料庫,底下使用者清單也可以正常更新。
server action form with input clear

小提醒:假如要在 Client Components 中加入 Server Components,記得要用傳 props 的方式 ( 可以參考 Day 12 的文章)

透過 props 傳 Server Actions
假如要在 Client Components 使用,也可以在 Server Components 中定義 Server Actions,再透過 props 傳到 Client Components:

'use client'
 
export default function ClientComponent({ serverAction }) {
  return (
    <form action={serverAction}>
      <input type="text" name="name" />
      <button type="submit">Update Item</button>
    </form>
  )
}
import React from 'react'
import ClientComponent from './ClientComponent'

export default function ServerComponent() {
  const saveData = () => {
    'use server';
    ...
  };
    
  return (
    <>
    <ClientComponent serverAction={saveData} />
    </>
  )
}

加入 Loading 效果

從上面的 demo 影片可以發現,按下「提交表單」到使用者清單更新,會有一段等待時間。假如想強化 UX,可以搭配 useFormStatus() 在這段期間加入 loading 特效。

我們可以透過 useFormStatus 其中一個 value pending,來判斷表單提交是否還在處理中。

比方說,我可以讓表單 pending 時按鈕的文字顯示「loading...」:

/* app/users/Button.tsx */
'use client';
import { experimental_useFormStatus as useFormStatus } from 'react-dom';

export default function Button() {
  const { pending } = useFormStatus();

  return (
    <button
      type='submit'
    >
      {pending ? 'Loading...' : '提交表單'}
    </button>
  );
}

server action form with loading effect

小提醒:目前 useFormStatus 只能在 Client Components 中使用

加入 Error Hanlding

假如使用 API,可以透過 status code 或 response messages,讓 client 知道發生什麼錯誤。假如使用 Server Actions,當 actions 發生問題有辦法讓 client 知道嗎?

我們可以在 Server Actions 中加入 try...catch...邏輯,在 actions 執行成功或失敗時 return 一個 serializable 的物件 (ex: 一段文字):

/* app/utils/actions.ts */
export const saveData = async (formData: FormData) => {
  'use server';

  const input: UserData = {
    name: formData.get('name') as string,
    email: formData.get('email') as string,
    age: Number(formData.get('age')),
  };
  // 回傳一個{status: ...} 的物件告訴 client 執行結果
  try {
    await addDoc(collection(db, 'users'), input);
    revalidatePath('/users');
    return { status: 'success' };
  } catch (error) {
    return { status: 'error' };
  }
};

我們在 Form 中就可以依照回傳的 status,做後續處理:

/* app/users/Form.tsx */

'use client';
import { useRef } from 'react';
import { saveData } from '../utils/action';

export default function Form({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLFormElement>(null);

  const onSubmit = async (formData: FormData) => {
    ref.current?.reset();
    // 透過 response.status 判斷是否發生錯誤
    const response = await saveData(formData);
    response.status === 'error' && alert('發生錯誤,請稍後再試');
  };

  return (
    <>
      <form ref={ref} action={onSubmit}>
      ...
      </form>
    </>
  );
}

server action error handling

小提醒:目前 error state 也只能在 Client Components 中使用

使用 Cookies

Server Actions 也可以設置、讀取、刪除 cookies:

'use server'
 
import { cookies } from 'next/headers'
 
export async function create() {
  const cart = await createCart()
  cookies().set('cartId', cart.id)
}

export async function read() {
  const auth = cookies().get('authorization')?.value
  // ...
}

export async function delete() {
  cookies().delete('name')
  // ...
}

至於 server-side 表單 input 的驗證,基本可以使用 input 的 typerequired 屬性。假如要比較進階的驗證,可以使用像是 Zod 等 library;當 Server Actions 處理完資料,除了 revalidate 外,也可以用 redirect 轉址到其他 URL,怕篇幅太長,就不多做介紹。有興趣的讀者可以參考官方文件:

Redirecting
Cookies

今天主要跟大家分享 Route Handler 和 Server Actions 的基本使用方式。但眼尖的讀者可能發現,Server Actions 的範例都是在表單提後觸發。

有辦法在一個表單中透過多個按鈕觸發多個 Server Actions 嗎?或甚至在表單以外使用 Server Actions 嗎?這部分就留到明天分享囉!

至於想了解 Server Actions 原理的讀者,我後天會和大家分享,再請稍加等候。

謝謝大家耐心的閱讀,我們明天見!


上一篇
Day 22 - 功能性路由 ( 二 ):Parallel Routes & Intercepting Routes
下一篇
Day 24 - 如何在表單外使用 Server Actions - formAction & useTransition
系列文
深入淺出,完整認識 Next.js 13 !30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
Jim
iT邦新手 4 級 ‧ 2024-03-13 23:49:03

完賽後補充:Server Actions 已於 Next.js 14 版本進入 stable 版,詳細資訊可參考官方貼文

官方貼文連結是 404

S.C iT邦新手 4 級 ‧ 2024-03-13 23:52:20 檢舉

已修正,感謝提醒!

我要留言

立即登入留言